作者:Adam Freeman
翻译:陈广
日期:2019-1-7
本章我将解释如何将 Entity Framework Core 应用于 ASP.NET Core MVC 项目,从添加 NuGet 包开始,通过创建基本数据模型、数据库架构和使用它的基础设施来工作。本章中创建的项目为接下来添加 Entity Framework Core 功能的项目奠定了基础。表11-1概述了这一章。
表 11-1:本章摘要
问题 | 解决方案 | 清单 |
---|---|---|
启用 EF Core 命令行工具 | 使用DotNetCliToolReference 元素向 .csproj 文件添加一个条目 |
10 |
为应用程序提供对 EF Core 功能的访问 | 创建数据库 context 类 | 11 |
准备用于数据库存储的数据模型类 | 确保所有属性都有get 和set 访问器,并添加主键属性。 |
12 |
向 EF Core 提供它应该使用的数据库的详细信息 | 定义连接字符串 | 14 |
准备 EF Core 使用的数据库 | 创建并应用数据库迁移 | 17 |
确保查询被数据库处理 | 使用IQueryable<T> 接口 |
24-26 |
避免重复查询 | 在枚举结果之前强制执行查询 | 27,28 |
在本章中,我将创建一个 ASP.NET Core 所需的最小内容的项目。然后添加程序包、类和配置组件以创建一个使用 Entity Framework Core 的 MVC 应用程序。
要创建项目,在 Visual Studio 文件菜单中选择【新建】➤【项目】,并使用 【ASP.NET Core Web 应用程序】模板来创建一个名为 DataApp 的新项目,如图11-1所示。
单击【确定】按钮进入到下一个对话框窗口,确保在列表中选中【ASP.NET Core 2.2】,并单击【空】模板,如图11-2所示。单击【确定】关闭对话框窗口并创建项目。
MVC 应用程序中的数据模型是使用常规 C# 类定义的,按惯例在 Models 文件夹中定义。Entity Framework Core 对于数据模型类的位置没有任何特殊的要求,并且将很高兴使用 MVC 约定。
若要向示例项目添加数据模型类,请创建 Models 文件夹并向其添加一个名为 Product.cs 的 C# 文件,代码如清单11-1所示。
清单 11-1:Models 文件夹下的 Product.cs 文件的内容
namespace DataApp.Models
{
public class Product
{
public string Name { get; set; }
public string Category { get; set; }
public decimal Price { get; set; }
}
}
数据模型类往往是属性的简单集合,这使得它们易于使用,并确保它们可用 JSON 等格式表示,如第10章所述,JSON 通常由HTTP Web service 使用。清单11-1中的Product
类定义了Name
、Category
和Price
属性,并且是第 1 部分中的运动商店示例中数据模型的简化。我将在后面章节中添加更为复杂的数据模型,但这些已足够开始。
【空】模板创建了一个基础的 ASP.NET Core 项目,需要额外配置以启用 MVC 框架。在Startup
类中添加清单11-2所示的语句以启用 MVC 框架和用于开发的中间件组件。
清单 11-2:DataApp 文件夹下的 Startup.cs 文件,启用服务和中间件
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace DataApp
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseDeveloperExceptionPage();
app.UseStatusCodePages();
app.UseStaticFiles();
app.UseMvcWithDefaultRoute();
}
}
}
这些更改支持开发者友好的错误消息,添加对静态内容(如 HTML 和 CSS 文件)的支持,并设置具有默认路由配置的 MVC 框架。
现在 MVC 框架已经启用,我可以创建控制器和视图以处理 HTTP 请求。创建一个 Controllers 文件夹并向其添加一个名为 HomeController.cs 的文件,代码如清单11-3所示。
清单 11-3:Controllers 文件夹下的 HomeController.cs 文件的内容
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
public IActionResult Index()
{
return View(new Product[] {
new Product { Name = "P1", Category = "Cat1", Price = 10 },
new Product { Name = "P2", Category = "Cat2", Price = 20 },
new Product { Name = "P3", Category = "Cat3", Price = 30 },
});
}
}
}
此控制器只有一个 action 方法,名为Index
,它创建了一个占位符Product
对象的集合,并作为视图模型对象传递给默认视图。这些静态类在之后章节中使用数据库以及 Entity Framework Core 建立并运行后被替换掉。
要为应用程序提供一致的布局,请创建一个名为 Views/Shared 的文件夹,并添加一个名为 _Layout.cshtml 的 Razor 布局页面,内容如清单11-4所示。
清单 11-4:Views/Shared 文件夹下的 _Layout.cshtml 文件的内容
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>@ViewData["Title"]</title>
<link rel="stylesheet" href="~/lib/twitter-bootstrap/css/bootstrap.min.css" />
</head>
<body>
<div class="p-2">
<h4 class="bg-primary text-center p-2 text-white">@ViewData["Title"]</h4>
@RenderBody()
</div>
</body>
</html>
为创建 Home 控制器中的Index
action 方法所对应的视图,请创建 Views/Home 文件夹并向其中添加一个名为 Index.cshtml 的 Razor 视图,内容如清单11-5所示。
清单 11-5:Views/Home 文件夹下的 Index.cshtml 文件的内容
@model IEnumerable<Product>
@{
ViewData["Title"] = "Products";
Layout = "_Layout";
}
<table class="table table-sm table-striped">
<thead>
<tr><th>Name</th><th>Category</th><th>Price</th></tr>
</thead>
<tbody>
@foreach (var p in Model)
{
<tr>
<td>@p.Name</td>
<td>@p.Category</td>
<td>$@p.Price.ToString("F2")</td>
</tr>
}
</tbody>
</table>
视图使用从控制器接收的Product
对象集合作为其视图模型,在 HTML 表中生成行,显示Name
、Category
和Price
属性的值。
为启用标签助手,我在 Views 文件夹下创建了一个名为 _ViewImports.cshtml 的视图导入页面,并添加如清单11-6所示的语句。
清单 11-6:Views 文件夹下的 _ViewImports.cshtml 文件的内容
@using DataApp.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
清单11-4中的布局文件包含一个引用 Bootstrap 框架 CSS 样式表的link1
元素。我用它在整本书中对 HTML 内容进行样式化。如果 wwwroot 文件夹不存在,请先建立它。为了将 Bootstrap 添加到示例项目中,我在 DataApp 项目中单击鼠标右键,在弹出菜单中选择【添加】➤【添加客户端库】,并将 twitter-bootstrap 添加至项目中。最终生成的 libman.json 配置文件代码清单11-7所示:
清单 11-7:DataApp 文件夹下的 libman.json 文件的内容
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "twitter-bootstrap@4.1.3",
"destination": "wwwroot/lib/twitter-bootstrap/"
}
]
}
更改 ASP.NET Core 将使用的端口来接收请求将使示例更容易跟着做。编辑 Properties 文件夹中的 launchSettings.json 文件,并更改 URL,如清单11-9所示。
清单 11-9:Properties 文件夹下的 launchSettings.json 文件,更改 HTTP 端口
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5000",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"DataApp": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
该文件中的 URL 用于在应用程序使用 IIS Express 启动和从命令行运行时配置该应用程序。本书中的示例是从命令行运行的,这样就可以很容易地看到日志消息。
使用 Entity Framework Core 需要比平时更多的命令行工作,这意味着从命令行启动应用程序通常是最自然的工作方式,特别是因为它提供了对有用日志消息的轻松访问。打开命令提示符或 PowerShell 窗口,导航到 DataApp 项目文件夹(包含 Startup.cs 类文件的文件夹),并运行清单11-10所示的命令。
清单 11-10:测试示例应用程序
dotnet run
打开一个新的浏览器窗口,并导航至 http://localhost:5000,你将看到图11-3所示的响应。一旦确认应用程序正确运行,请使用【Ctrl + C】来停止它。
现在已经有了基本的 ASP.NET Core MVC 应用程序可以使用,是时候配置 Entity Framework Core 了,以便它能够代表 MVC 应用程序存储并检索数据。
Visual Studio 创建的项目被配置为使用 ASP.NET Core 元包,该包是在 ASP.NET Core 2 中引入的。单个 NuGet 包提供对在项目上开始开发所需的所有单个包的访问,包括 ASP.NET Core、MVC 框架和 Entity Framework Core,这与早期版本不同,后者要求在开发开始之前手动添加一长串 NuGet 包。
尽管元包非常有用,但是对于使用 Entity Framework Core 的项目,仍然需要增加一个。许多重要的 Entity Framework Core 操作都是使用命令行工具执行的,提供命令行特性的 NuGet 包不是元包的一部分,必须手动安装。
清单11-11显示了将工具包添加到 DataApp.csproj 文件中,您可以通过右键单击【解决方案资源管理器】中的 DataApp 项目项并从弹出菜单中选择【编辑 DataApp.csproj】来访问该文件。
译者注:.NET 2.1 及之后的版本默认已经安装了此工具,可以跳过此步骤。此处为原文的针对 .NET 2.0 版本的内容
清单 11-11:DataApp 文件夹下的 DataApp.csproj 文件,添加工具包
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.5" />
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
</ItemGroup>
</Project>
此包是使用DotNetCliToolReference
元素添加的,而不是使用用于常规包的PackageReference
。DotNetCliToolReference
元素用于向项目添加命令行工具包,而常规 NuGet 工具不支持它,这也是必须手动添加文件的原因。
数据库 context 类向 Entity Framework Core 提供数据模型类的详细信息,这些类的实例将存储在数据库中。context 类是使用 Entity Framework Core 的最重要的组件之一。若要创建 context 类,请在 DataApp/Models 文件夹中添加名为 EFDatabaseContext.cs 的类文件,并使用清单11-12所示的代码。
清单 11-12:Models 文件夹下的 EFDatabaseContext.cs 文件的内容
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
namespace DataApp.Models
{
public class EFDatabaseContext : DbContext
{
public EFDatabaseContext(DbContextOptions<EFDatabaseContext> opts)
: base(opts) { }
public DbSet<Product> Products { get; set; }
}
}
EFDatabaseContext
类的构造函数接受DbContextOptions
对象,该对象用于配置 Entity Framework Core。对于 ASP.NET Core MVC 应用程序,此配置是在Startup
类中执行的,如本章后面所示,因此构造函数只需将 options 对象传递给基类。
提示:context 类必须定义一个构造器,即使它不包含任何代码。如果创建一个没有构造器的 context 类,将收到一个错误。
数据库 context 类通过定义返回DbSet<T>
的属性告诉 Entity Framework Core 哪些数据模型类将存储在数据库中,其中类型参数T
指定数据模型类。
Entity Framework Core 需要能够唯一地标识Product
对象,以便它们可以存储在数据库中并分配主键。默认情况下,Entity Framework Core 查找一个名为Id
或<Type>Id
的属性作为键,这意味着对于Product
类,主键属性可以被称为Id
或ProductId
。要准备按 Entity Framework Core 存储的Product
类,请编辑Product
类以添加清单11-13所示的属性。
清单 11-13:Models 文件夹下的 Product.cs 文件,添加一个主键属性
namespace DataApp.Models
{
public class Product
{
public long Id { get; set; }
public string Name { get; set; }
public string Category { get; set; }
public decimal Price { get; set; }
}
}
主键属性的类型影响 Entity Framework Core 处理它的方式。对于数字类型(如int
和long
),架构会配置为数据库负责生成唯一的主键值。这对于 ASP.NET Core MVC 应用程序非常有用,因为应用程序可以有多个实例共享相同的数据库,并且协调唯一的值可能很复杂。对于其它类型,如字符串,属性仍被用作主键,但由应用程序负责生成值。我在第19章描述了主键的高级选项。
要将静态数据替换为使用 Entity Framework Core 的对象,打开 HomeController.cs 文件并更改为清单11-14所示代码。
清单 11-14:Controllers 文件夹下的 HomeController.cs 文件,使用真实数据
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private EFDatabaseContext context;
public HomeController(EFDatabaseContext ctx)
{
context = ctx;
}
public IActionResult Index()
{
return View(context.Products);
}
}
}
控制器的构造器将接收一个 context 对象,它由 ASP.NET Core 依赖注入功能提供,依赖注入在应用程序的 MVC 和 Entity Framework Core 部分提供链接。
Index
action 方法的工作是为它的视图提供一个Product
对象集合用于显示。这个集合现在是通过读取数据库 context 的Products
属性来获得的,该属性返回一个DbSet<Product>
对象。DbSet<Product>
类实现了IEnumerable<Product>
接口,这意味着 Index.cshtml 视图可以在没有任何更改的情况下枚举Product
对象序列(尽管如此,正如我在《避免IEnumerable
与IQueryable
陷阱》一节中解释的那样,使用此接口时必须小心)。
Entity Framework Core 不绑定到任何特定的数据库服务器。相反,特定数据库服务器(如 MySQL 或 Microsoft SQL Server)所需的所有功能都包含在一个称为数据库提供程序的包中。这意味着 Entity Framework Core 可用于任何拥有提供程序的数据库中。配置数据库提供程序通常需要两个步骤:配置连接字符串以便数据库提供程序知道如何与数据库服务器进行通信;配置应用程序,以便 Entity Framework Core 知道 context 类将使用哪个数据库提供程序。
连接字符串向数据库提供程序提供连接到特定数据库服务器所需的信息。每个提供程序使用不同格式的连接字符串,但通常包含网络连接所需的主机名和端口,身份验证所需的用户凭据以及应用程序要使用的数据库的名称。大多数项目使用数据库服务器的不同实例,因此开发人员执行的 action 与实际客户数据保持分离,这意味着在不同的时间需要不同的连接字符串。更改的配置设置通常是在 appsettings.json 文件中定义的,这样就可以在不需要重新编译项目的情况下修改这些设置,并且该文件是使用 Entity Framework Core 的应用程序中连接字符串的通常存放的地方。
在【解决方案资源管理器】中右键单击 DataApp 项目,选择【添加】➤ 【新建项】,并使用【应用设置文件】模板来创建一个名为 appsettings.json 的文件,其内容如清单11-15所示。
译者注:.NET 2.1 及之后的版本在使用【空】模板创建项目时默认已经创建了 appsettings.json 文件,可以直接打开使用。此处为原文的针对 .NET 2.0 版本的内容
警告:连接字符串必须输入为单个未中断的行。连接字符串太长,无法显示在打印页面上的一行上,这是清单11-15中令人尴尬的格式设置的原因。如果有疑问,请查看本书 GitHub 存储库中本章的 appsettings.json 文件( https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc )。
清单 11-15:DataApp 文件夹下的 appsettings.json 文件的内容
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=DataAppDb;MultipleActiveResultSets=true"
}
}
连接字符串包含将用于连接 SQL Server 的详细信息。此连接字符串的每个部分都在表11-2中进行了描述。
注意:此连接字符串比大多数连接字符串更简单,因为它使用了与 Visual Studio 一起安装的无配置 LocalDB 功能。用于部署数据库服务器的连接字符串将更加复杂,通常包括网络连接的主机名和 TCP 端口的详细信息,以及用于身份验证的凭据。
表 11-2:数据库连接字符串中的元素
名称 | 描述 |
---|---|
Server | 此设置指定服务器的主机名。例如,此处为(localdb)\\MSSQLLocalDB ,因为数据库是通过 SQL Server LocalDB 功能访问的。对于其它类型的数据库,连接字符串通常包含用于连接数据库服务器的主机名和 TCP 端口。 |
Database | 此设置指定了数据库的名称。例如此处为DataAppDB 。 |
MultipleActiveResultSets | 此设置决定了一个客户端是还可以在单个连接上执行多个活动的 SQL 语句。对于 MVC 应用程序,通过设置为true ,因为它避免了在 Razor 视图中枚举数据对象集合时的常见异常。 |
您必须配置应用程序,以使 Entity Framework Core 知道您需要哪个数据库提供程序,以及它将使用哪个连接字符串访问数据库。这是在Startup
类中完成的,如清单 11-16所示。
清单 11-16:DataApp 文件夹下的 Startup.cs 文件,配置数据库提供程序
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using DataApp.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace DataApp
{
public class Startup
{
public Startup(IConfiguration config) => Configuration = config;
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
string conString = Configuration["ConnectionStrings:DefaultConnection"];
services.AddDbContext<EFDatabaseContext>(options =>
options.UseSqlServer(conString));
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseDeveloperExceptionPage();
app.UseStatusCodePages();
app.UseStaticFiles();
app.UseMvcWithDefaultRoute();
}
}
}
ASP.NET Core 在应用程序启动时自动加载 appsettings.json 文件的内容,并使其包含的配置设置(包括连接字符串)通过 IConfiguaton 接口获得。在清单11-16中,构造函数接收IConfiguration
对象,并将其分配给一个名为Configuration
的属性,然后在ConfigureServices
方法中使用它来配置 Entity Framework Core。
Entity Framework Core 的配置是在ConfigureServices
方法中完成的,从配置数据获取连接字符串开始。我使用了最直接的方法来获取连接字符串,即使用数组类型的索引器在单个字符串中指定配置属性的名称。字符串部分表示 appsettings.json 文件中的结构,因此ConnectionStrings:DefaultConnection
对应于appsettings.json
文件的ConnectionStrings
部分中的DefaultConnection
属性,如下所示:
string conString = Configuration["ConnectionStrings:DefaultConnection"];
拥有连接字符串后,配置数据库提供程序并使用AddDbContext
扩展方法将其与数据库 context 类关联。AddDbContext
方法的类型参数指定 context 类,该方法接收用于选择和配置数据库提供程序的DbContextOptionsBuilder
对象,如下所示:
services.AddDbContext<EFDatabaseContext>(options => options.UseSqlServer(conString));
此语句标识 Entity Framework Core 的EFDatabaseContext
类。UseSqlServer
方法选择 SQL Server 的数据库提供程序,并告诉它使用从 appsettings.json 文件中读取的连接字符串连接到数据库。
本书的很多示例是建立在理解应用程序中的 C# 语句是如何转化为发送到数据库的 SQL 查询的基础之上的。配置 ASP.NET Core 日志系统,以便它可以显示来自 Entity Framework Core 的有用消息,请参照清单11-7所示向 appsettings.json 文件添加语句。
清单 11-7:DataApp 文件夹下的 appsettings.json 文件,启用 EF Core 日志
{
"Logging": {
"LogLevel": {
"Default": "None",
"Microsoft.EntityFrameworkCore": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=DataAppDb;MultipleActiveResultSets=true"
}
}
日志系统配置为Program
类执行的 ASP.NET Core 初始化进程的一部分。日志记录系统读取Logging:LogLevel
配置数据,并使用它选择将要显示的日志消息。清单11-17中的日志配置从Microsoft.EntityFrameworkCore
命名空间中选择Information
级别的消息,其中将包括从应用程序发送到数据库服务器的 SQL 查询的详细信息。并禁用来自其他命名空间的日志消息。
Entity Framework Core 使用迁移来创建或更改数据库,以便用来存储应用程序的数据。使用命令提示符或 PowerShell 窗口在 DataApp 文件夹下运行清单11-18所示命令。第一个命令创建了一个新的迁移,它包含创建架构的命令。第二个命令将迁移应用到数据库。
提示:如果您运行清单11-18的命令时收到一个“build failed”消息,可能的原因是应用程序还在运行。停止应用程序,等一会,然后再运行命令去创建和应用迁移。
清单 11-18:创建和应用数据库迁移
dotnet ef migrations add Initial
dotnet ef database update
我在第13章详细解释了迁移是如何工作的,但这些命令的综合效果是创建 DataAppDb 数据库,并创建一个名为 Products 的数据表,它带有 Id、Name、Category 和 Price 列,它们对应于Product
实体类所定义的属性。
清单11-17中创建的日志配置确保您将看到dotnet ef database update
命令在应用迁移时发送到数据库的 SQL 命令。最重要的部分展示了迁移如何创建了一个新表:Products,如下所示:
CREATE TABLE [Products] (
[Id] bigint NOT NULL IDENTITY,
[Category] nvarchar(max) NULL,
[Name] nvarchar(max) NULL,
[Price] decimal(18, 2) NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
您可以看到 SQL 的 CREATE TABLE
命令在 Products 表中创建了Product
模型类中所定义属性的对应列。这就是 Entity Framework Core 存储应用程序数据的方式:.NET 对象作为行存储在表中,每个属性的值都存储在自己的列中。当然,Entity Framework Core 做的更多,但是只要您记住对象到表行的映射,其他的一切都会更容易理解。
迁移还创建了一个名为 __EFMigrationsHistory 的表,Entity Framework Core 用它来跟踪应用了哪些迁移,但是对于示例应用程序来说,重要的是 Products 表,因为它将用来存储Product
对象。
为给数据库填充一些初始数据,选择【工具】➤【SQL Server】➤【New Query】,并在【服务器名称】字段输入(localdb)\MSSQLLocalDB
(注意,此字符串中只有一个\
字符,而不是 appsettings.json 文件中连接字符串定义中需要的两个)。
确保【身份验证】字段选择了【Windows 身份验证】,单击【数据库名称】字段,会显示由 LocalDB 创建的所有数据库清单,从下拉列表中选择【DataAppDb】。单击【连接】并在编辑器输入清单11-19所示内容。
清单 11-19:数据库播种
USE DataAppDb
INSERT INTO Products (Name, Category, Price) VALUES
( 'Kayak', 'Watersports', 275),
( 'Lifejacket', 'Watersports', 48.95),
( 'Soccer Ball', 'Soccer', 19.50),
( 'Corner Flags', 'Soccer', 34.95),
( 'Stadium', 'Soccer', 79500),
( 'Thinking Cap', 'Chess', 16),
( 'Unsteady Chair', 'Chess', 29.95),
( 'Human Chess Board', 'Chess', 75),
( 'Bling-Bling King', 'Chess', 1200)
SELECT Id, Name, Category, Price from Products
此文件的语句向 DataAppDb 数据库的 Products 表添加了行,并提供了Name
、Category
和Price
列的值。未给Id
列赋值,因为它们将由数据库生成,以确保惟一。
在【Visual Studio】的 【SQL】 菜单中选择【Execute】,Visual Studio 将向 SQL Server 发送 SQL 语句,它们将被执行,并产生表11-3所示的输出。
表 11-3:种子数据
Id | Name | Category | Price |
---|---|---|---|
1 | Kayak | Watersports | 275.00 |
2 | Lifejacket | Watersports | 48.95 |
3 | Soccer Ball | Soccer | 19.50 |
4 | Corner Flags | Soccer | 34.95 |
5 | Stadium | Soccer | 79500.00 |
6 | Thinking Cap | Chess | 16.00 |
7 | Unsteady Chair | Chess | 29.95 |
8 | Human Chess Board | Chess | 75.00 |
9 | Bling-Bling King | Chess | 1200.00 |
所有的部分都准备好了,现在是启动应用程序的时候了。使用命令提示符或 PowerShell 窗口导航到 DataApp 项目文件夹并运行清单11-20所示的命令。
清单 11-20:运行示例应用程序
dotnet run
应用程序编辑并启动,集成的 ASP.NET Core web 服务器将开始侦听 HTTP 端口 5000 上的请求。
打开一个新的浏览器窗口,请求 http://localhost:5000,您将看到 Entity Framework Core 将向应用程序提供清单11-4所示的数据。确认应用程序操作正确后,在命令提示符下按【Ctrl + C】退出。
理解应用程序是如何工作的 在继续之前,值得花点时间了解应用程序是如何接收到图11-4中显示的数据的。
当 ASP.NET Core 运行时在5000端口接收到浏览器的 HTTP 请求时,将它发送给 MVC 框架,并使用路由系统选择 Home 控制器的
Index
action 产生一个响应。
Index
action 读取控制器通过其构造函数接收的EFDatabaseContext
对象的Products
属性,并接收一系列Product
对象。此序列作为视图模型传递给 Views/Home/Index.cshtml 视图,该视图枚举它所包含的对象,以便在 HTML 表中生成行。在枚举
Product
对象序列时,Entity Framework Core 从 DataAppDb 数据库读取Products
表的内容,该数据库由 SQL Server 通过其 LocalDB 功能管理。数据库的名称和如何连接到数据库服务器的详细信息包含在连接字符串中,定义在 appsettings.json 文件中,并在Startup
类中读取。
"DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=DataAppDb;
MultipleActiveResultSets=true"
包含数据的表的名称取自由
EFDatabaseContext
context 类定义的DbSet<T>
属性的名称。
public DbSet<Product> Products { get; set; }
Entity Framework Core 使用此名称在迁移中为数据库生成架构。从表中读取的数据行包含
Id
、Name
、Category
和Price
列的值,这些数据行用于创建 Index.cshtml 视图处理的Product
对象。这种方法的优雅之处在于,应用程序的每个部分很少需要了解项目的其余部分才能完成其工作。例如,Index.cshtml 视图不需要更改以处理来自数据库的数据;它是在将 Entity Framework Core 添加到项目之前编写的,用于处理
IEnumerable<Product>
。Entity Framework Core 负责将数据库中的行转换为Product
对象,并使处理数据的过程基本无缝。控制器需要进行一些更改,但是,正如您将在下一节中看到的那样,可以通过实现存储库模式来最小化这一点。如果您对后面章节中某些特性的工作方式感到困惑,那么复习此示例,学习如何将类、配置设置和数据库功能按顺序组合起来为应用程序的 MVC 部分提供数据。它将帮助您并提醒您,Entity Framework Core 并不神奇,即使您有时感觉到它的魔力。当您完成接下来的章节后,您将看到每个特性是如何实现的,并且您将看到一切都建立在这个示例所揭示的基础之上。
示例应用程序中的 Home 控制器直接使用EFDatabaseContext
对象访问数据库中的数据。这是一种很好的方法方法,但可以通过实现存储库模式对其进行改进。
存储库由定义可在应用程序中执行的数据操作的接口和执行实际工作的实现类组成。应用程序的 MVC 部分只使用接口,而在幕后,实现类使用数据库 context 执行数据操作。
使用存储库可以更容易地分离 MVC 组件并合并处理 Entity Framework Core 的代码,这使得单元测试应用程序的不同部分和切换到不同的数据库(甚至完全替换 Entity Framework Core 并使用不同的数据访问层)变得更加容易。如果没有存储库,处理数据的代码往往会在整个项目中传播,从而导致代码重复,并使有效的单元测试变得困难。
存储库的起点是定义一个允许通过 Entity Framework Core 读取数据的接口。在 Models 文件夹中创建一个名为 IDataRepository.cs 的类文件,并添加清单11-21所示的代码。
警告:不要在实际项目中实现存储库模式,直到您阅读了《避免 IEnumerable 与 IQueryable 陷阱》一节。清单11-21和清单11-22所示的代码包含了一个陷阱,用于对付粗心大意的人。
清单 11-21:Models 文件夹下的 IDataRepository.cs 文件内容
using System.Collections.Generic;
namespace DataApp.Models
{
public interface IDataRepository
{
IEnumerable<Product> Products { get; }
}
}
随着之后部分中的功能添加,存储库接口将变得更加复杂,但目前它只定义了一个名为Products
的属性,该属性将返回一系列Product
对象。通过将名为 EFDataRepository.cs 的类文件添加到 Models 文件夹中,创建存储库接口的实现,代码如清单11-22所示。
清单 11-22:Models 文件夹下的 EFDataRepository.cs 文件的内容
using System.Collections.Generic;
namespace DataApp.Models
{
public class EFDataRepository : IDataRepository
{
private EFDatabaseContext context;
public EFDataRepository(EFDatabaseContext ctx)
{
context = ctx;
}
public IEnumerable<Product> Products => context.Products;
}
}
实现类通过其构造函数接收EFDatabaseContext
对象,它使用该构造函数实现IRepository
接口所需的Products
属性。这似乎并不是向前迈出的一大步,但这意味着可以更新 Home 控制器,使其依赖于存储库接口,而不需要任何 context 类的知识,如清单11-23所示。
清单 11-23:Controllers 文件夹下的 HomeController.cs 文件,使用存储库
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private IDataRepository repository;
public HomeController(IDataRepository repo)
{
repository = repo;
}
public IActionResult Index()
{
return View(repository.Products);
}
}
}
Index
action 方法的工作方式与引入存储库之前相同,但通过存储库接口获得它的Product
对象集合。这使得为单元测试创建模拟实现类或创建使用不同数据层的不同实现变得容易。
通过对清单11-24所示的Startup
类进行更改,配置依赖项注入特性以使用EFDataRepository
类作为IRepository
接口的实现。
清单 11-24:DataApp 文件夹下的 Startup.cs 文件,配置依赖注入
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using DataApp.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace DataApp
{
public class Startup
{
public Startup(IConfiguration config) => Configuration = config;
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
string conString = Configuration["ConnectionStrings:DefaultConnection"];
services.AddDbContext<EFDatabaseContext>(options =>
options.UseSqlServer(conString));
services.AddTransient<IDataRepository, EFDataRepository>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseDeveloperExceptionPage();
app.UseStatusCodePages();
app.UseStaticFiles();
app.UseMvcWithDefaultRoute();
}
}
}
使用dotnet run
命令再次运行应用程序,您将看到相同的数据。不同之处在于,数据是通过存储库接口获得的,这打破了应用程序的 MVC 和 EF Core 部分之间的紧耦合。
在上一节中,我实现存储库接口的方式存在一个陷阱。需要对应用程序进行更改以揭示问题,如清单11-25所示,它更新了 Home 控制器上的Index
action 方法,以便使用 LINQ 筛选传递给视图的对象。
清单 11-25:Controllers 文件夹下的 HomeController.cs 文件,筛选对象
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
using System.Linq;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private IDataRepository repository;
public HomeController(IDataRepository repo)
{
repository = repo;
}
public IActionResult Index()
{
return View(repository.Products.Where(p => p.Price > 25));
}
}
}
LINQ 查询只选择其价格属性值大于25的Product
对象。在 DataApp 文件夹中使用dotnet run
命令启动应用程序,并使用 web 浏览器请求 http://localhost:5000。您将看到只显示那些Price
属性大于25的Product
对象,如图11-5所示。
如果检查写入命令提示符的日志消息,可以看到 Entity Framework Core 用于从数据库获取数据的 SQL 查询。
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[Price]
FROM [Products] AS [p]
尽管只需要Products
表中的一些行,但 Entity Framework Core 使用的查询不执行任何筛选,而是向数据库询问所有Product
数据。这是 LINQ 计算其查询的方式中的一个怪癖,这意味着存储在数据库中的所有产品对象都被检索到应用程序中,只有在此之后才是应用Where
方法指定的筛选。
对于示例应用程序,这意味着所有数据都从数据库中的 Products 表中读取,并用于创建Product
对象,然后由 LINQ Where
方法检查这些对象,如果它们的Price
太低,则丢弃它们。
对于拥有少量数据的应用程序来说,这不是一个问题。示例应用程序数据库中存储了9个Product
对象。所有9个将被 SQL 查询读取,然后用于创建Product
对象,但其中只有7个将由Where
方法选择。对于如此少量的数据,Entity Framework Core 检索两个额外的对象,然后被 MVC 控制器丢弃这一事实不值得担心。但是,对于拥有大量数据的应用程序来说,情况就不同了,在这些应用程序中,检索大量的表行,使用它们创建对象,然后丢弃它们是一种浪费和昂贵的操作。
要解决这个问题,需要对存储库接口及其实现类进行更改。清单11-26显示了对接口的更改。
清单 11-26:Models 文件夹下的 IDataRepository.cs 文件,解决数据检索问题
using System.Collections.Generic;
using System.Linq;
namespace DataApp.Models
{
public interface IDataRepository
{
IQueryable<Product> Products { get; }
}
}
IQueryable
接口派生自IEnumerable
,但表示数据库应该处理的查询。清单11-27对实现类进行了相应的更改,以便它反映Products
属性的新返回类型。
清单 11-27:Models 文件夹下的 EFDataRepository.cs 文件,解决数据检索问题
using System.Collections.Generic;
using System.Linq;
namespace DataApp.Models
{
public class EFDataRepository : IDataRepository
{
private EFDatabaseContext context;
public EFDataRepository(EFDatabaseContext ctx)
{
context = ctx;
}
public IQueryable<Product> Products => context.Products;
}
}
使用dotnet run
重启应用程序,并使用浏览器请求 http://localhost:5000。在写入到命令提示符的消息中,您将看到发送给 SQL Server 的不同查询。
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE [p].[Price] > 25.0
IQueryable
接口的使用改变了对 LINQ 查询的评估方式,并确保筛选是由数据库服务器而不是由 MVC 应用程序执行的。只检索符合筛选条件的数据库中的数据,并且不会创建任何立即丢弃的对象。
使用IQueryable<T>
接口的缺点是很容易意外地生成比您预期更多的数据库查询。为了演示这个问题,我修改了 Home 控制器中的 action 方法,以便它对从 Entity Framework Core 接收到的数据执行多个操作,如清单11-28所示。
清单 11-28:Controllers 文件夹下的 HomeController.cs 文件,多个数据操作
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
using System.Linq;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private IDataRepository repository;
public HomeController(IDataRepository repo)
{
repository = repo;
}
public IActionResult Index()
{
var products = repository.Products.Where(p => p.Price > 25);
ViewBag.ProductCount = products.Count();
return View(products);
}
}
}
Index
方法中的新语句调用从存储库获取的对象集合上的Count
方法。这似乎是无害的,但是如果您使用dotnet run
启动应用程序,运行并请求 http://localhost:5000,日志消息将显示向数据库发送了两个查询。第一个查询要求数据库服务器计数与过滤器匹配的产品数量,如下所示:
SELECT COUNT(*)
FROM [Products] AS [p]
WHERE [p].[Price] > 25.0
第二个查询实际上检索数据对象。
SELECT [p].[Id], [p].[Category], [p].[Name], [p].[Price]
FROM [Products] AS [p]
WHERE [p].[Price] > 25.0
IQueryable<T>
接口每次计算时都会触发一个新的查询,这意味着在调用Count
方法时发送了一个查询,而第二个查询则发生在 Razor 视图枚举Product
对象以填充发送到浏览器的 HTML 表时。查询以与代码的出现顺序并不相同,因为直到 action 方法完成后才会渲染 Razor 视图。
根本的问题是,Entity Framework Core 对 LINQ 查询没有足够的洞察力,无法意识到这两个数据操作可以使用单个数据库查询来处理。在这种情况下,可以通过使用ToArray
或ToList
方法将IQueryable<T>
对象转换为常规的IEnumerable<T>
来避免额外的查询,如清单11-29所示。
清单 11-29:Controllers 文件夹下的 HomeController.cs 文件,避免额外查询
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
using System.Linq;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private IDataRepository repository;
public HomeController(IDataRepository repo)
{
repository = repo;
}
public IActionResult Index()
{
var products = repository.Products.Where(p => p.Price > 25).ToArray();
ViewBag.ProductCount = products.Count();
return View(products);
}
}
}
ToArray
方法强制对查询进行计算,并生成一个对象集合,这些对象可以在不触发其他查询的情况下进一步处理,这意味着Count
方法对已经从数据库检索的数据进行操作,而不是触发新的查询。
注意:本节中的示例可能给您的印象是,您面临查询更多数据或发送超出需要的查询的持续风险。关键是检查来自应用程序的日志消息,以确保您得到了所需的行为,一旦您习惯了使用 Entity Framework Core,这将成为第二天性。
使用IQueryable<T>
接口时遇到的一个意外问题是,它向应用程序的其余部分公开了如何管理数据的详细信息。这意味着每次编写新的 action 方法时,都必须注意IQueryable<T>
接口是否被使用,并确保只使用基本数量的查询来请求所需的数据。
另一种更好的方法是隐藏如何在存储库中获取数据的细节,这样就可以不用担心数据是如何获得的。对于示例应用程序,这意味着将从控制器中选择数据的代码移动到存储库中。在清单11-30中,我将存储库接口定义的Products
属性替换为控制器执行的查询的方法。这允许存储库返回一个IEnumerable<Product>
对象,该对象不会泄漏实现细节,但也不会检索并丢弃数据或触发意外查询。
提示:您不必在应用程序中隐藏数据操作。如果这样做,则不太可能产生意外的查询或请求过多的数据,但结果可能不那么灵活,而且确实会将复杂性转移到存储库中。我倾向于在自己的项目以及本书中的示例中混合和匹配方法,并且仔细观察应用程序生成的日志消息,以确保我理解应用程序的 MVC 部分中的代码被转换为 SQL 查询的方式。
清单 11-30:Models 文件夹下的 IDataRepository.cs 文件,定义查询方法
using System.Collections.Generic;
using System.Linq;
namespace DataApp.Models
{
public interface IDataRepository
{
IEnumerable<Product> GetProductsByPrice(decimal minPrice);
}
}
Products
属性被替换为GetProductsByPrice
方法。应用程序的 MVC 部分将使用此方法,而不是直接使用 LINQ 进行自己的查询。清单11-31显示了存储库实现类所需的更改。
清单 11-31:Models 文件夹下的 EFDataRepository.cs 文件,定义查询方法
using System.Collections.Generic;
using System.Linq;
namespace DataApp.Models
{
public class EFDataRepository : IDataRepository
{
private EFDatabaseContext context;
public EFDataRepository(EFDatabaseContext ctx)
{
context = ctx;
}
public IEnumerable<Product> GetProductsByPrice(decimal minPrice)
{
return context.Products.Where(p => p.Price >= minPrice).ToArray();
}
}
}
context 类的DbSet<Product>
属性实现了IQueryable<Product>
接口。这意味着我只需返回 LINQ 查询的结果,而无需任何类型的转换,但我使用ToArray
方法来确保数据的继续使用不会触发任何额外的查询。
清单11-32更新了 Home 控制器的Index
action 方法,以便它可以使用GetProductsByPrice
方法,留给存储库处理数据的筛选。
清单 11-32:Controllers 文件夹下的 HomeController.cs 文件,使用数据查询方法
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
using System.Linq;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private IDataRepository repository;
public HomeController(IDataRepository repo)
{
repository = repo;
}
public IActionResult Index()
{
var products = repository.GetProductsByPrice(25);
ViewBag.ProductCount = products.Count();
return View(products);
}
}
}
action 方法能够获得它所需的筛选数据,而不必关心如何获得它,并且可以对该数据执行额外的操作 —— 例如使用LINQ Count
方法 —— 而无需考虑这样做是否会产生不良的副作用。
要测试更改,使用dotnet run
命令启动应用程序,并导航至 http://localhost:5000。浏览器显示的对象并没有更改,但存储库不再暴露任何实现细节,这改进了应用程序的 Entity Framework Core 和 MVC 框架部分之间的分离,使处理应用程序数据的过程变得不那么复杂,尽管不那么灵活,因为控制器只能从数据库接收特定的数据子集。
为结束本章,我将通过为应用程序所需的最常见操作添加 action 方法和视图,以及存储库中支持它们的方法来完成应用程序的 MVC 部分。使用 Entity Framework Core 操作的实现是下一章的主题,因此存储库类现在只需将一条消息写入命令提示符。
大多数 MVC 应用程序需要五个核心数据操作:检索单个项、检索所有项、创建新项、更新现有项和删除项。一旦您设置了这五个操作并开始工作,其他所有操作都将就位。编辑存储库接口以添加清单11-33所示的方法。
清单 11-33:Models 文件夹下的 IDataRespository.cs 文件,添加方法
using System.Collections.Generic;
using System.Linq;
namespace DataApp.Models
{
public interface IDataRepository
{
Product GetProduct(long id);
IEnumerable<Product> GetAllProducts();
void CreateProduct(Product newProduct);
void UpdateProduct(Product changedProduct);
void DeleteProduct(long id);
}
}
GetProduct
和DeleteProduct
方法所定义的参数接收一个被存储对象的主键值,它对应于Id
属性。CreateProduct
和UpdateProduct
接收Product
对象,GetAllProducts
没有参数。
编辑EFDataRepository
类,并将清单11-34中所示的方法添加为占位符,功能将在后面的章节中添加。除了GetAllProducts
方法,它只是返回 context 对象的Product
属性的值,以提供对数据库中所有Product
对象的访问。
清单 11-34:Models 文件夹下的 EFDataRepository.cs 文件,添加方法
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
namespace DataApp.Models
{
public class EFDataRepository : IDataRepository
{
private EFDatabaseContext context;
public EFDataRepository(EFDatabaseContext ctx)
{
context = ctx;
}
public Product GetProduct(long id)
{
Console.WriteLine("GetProduct: " + id);
return new Product();
}
public IEnumerable<Product> GetAllProducts()
{
Console.WriteLine("GetAllProducts");
return context.Products;
}
public void CreateProduct(Product newProduct)
{
Console.WriteLine("CreateProduct: "
+ JsonConvert.SerializeObject(newProduct));
}
public void UpdateProduct(Product changedProduct)
{
Console.WriteLine("UpdateProduct : "
+ JsonConvert.SerializeObject(changedProduct));
}
public void DeleteProduct(long id)
{
Console.WriteLine("DeleteProduct: " + id);
}
}
}
Newtonsoft.Json 程序包是 ASP.NET Core MVC 依赖的,作为 MVC 包的依赖项安装的。在此清单中,它用于序列化从CreateProduct
和UpdateProduct
方法中接收的对象,这样它们就可以被写入控制台并易于测试。
要添加将使用新数据操作的 action,请编辑 Home 控制器以添加清单11-35所示的方法。这些方法使用 ASP.NET Core MVC 约定和特性来处理请求,包括后 POST-only 方法和模型绑定。
清单 11-35:Controllers 文件夹下的 HomeController.cs 文件,添加 Action 方法
using Microsoft.AspNetCore.Mvc;
using DataApp.Models;
using System.Linq;
namespace DataApp.Controllers
{
public class HomeController : Controller
{
private IDataRepository repository;
public HomeController(IDataRepository repo)
{
repository = repo;
}
public IActionResult Index()
{
return View(repository.GetAllProducts());
}
public IActionResult Create()
{
ViewBag.CreateMode = true;
return View("Editor", new Product());
}
[HttpPost]
public IActionResult Create(Product product)
{
repository.CreateProduct(product);
return RedirectToAction(nameof(Index));
}
public IActionResult Edit(long id)
{
ViewBag.CreateMode = false;
return View("Editor", repository.GetProduct(id));
}
[HttpPost]
public IActionResult Edit(Product product)
{
repository.UpdateProduct(product);
return RedirectToAction(nameof(Index));
}
[HttpPost]
public IActionResult Delete(long id)
{
repository.DeleteProduct(id);
return RedirectToAction(nameof(Index));
}
}
}
要完成应用程序的 MVC 部分,在 Views/Shared 文件夹下添加一个名为 Editor.cshtml 的 Razor 视图,并添加清单11-36所示的标记。当用户想编辑或创建一个项并依赖于清单11-35中的Create
和Edit
方法设置的 view bag 属性来更改其外观和提交 HTML 表单的操作时,将使用此视图。
清单 11-36:Views/Shared 文件夹下的 Editor.cshtml 文件的内容
@model DataApp.Models.Product
@{
ViewData["Title"] = ViewBag.CreateMode ? "Create" : "Edit";
Layout = "_Layout";
}
<form asp-action="@(ViewBag.CreateMode ? "Create" : "Edit")" method="post">
<div class="form-group">
<label asp-for="Name"></label>
<input asp-for="Name" class="form-control" />
</div>
<div class="form-group">
<label asp-for="Category"></label>
<input asp-for="Category" class="form-control" />
</div>
<div class="form-group">
<label asp-for="Price"></label>
<input asp-for="Price" class="form-control" />
</div>
<div class="text-center">
<button class="btn btn-primary" type="submit">Save</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</div>
</form>
最后,编辑 Index 视图以添加删除项的按钮,并开始创建和编辑进程,如清单11-37所示。
清单 11-37:Views/Home 文件夹下的 Index.cshtml 文件,添加按钮
@model IEnumerable<Product>
@{
ViewData["Title"] = "Products";
Layout = "_Layout";
}
<table class="table table-sm table-striped">
<thead>
<tr><th>ID</th><th>Name</th><th>Category</th><th>Price</th></tr>
</thead>
<tbody>
@foreach (var p in Model)
{
<tr>
<td>@p.Id</td>
<td>@p.Name</td>
<td>@p.Category</td>
<td>$@p.Price.ToString("F2")</td>
<td>
<form asp-action="Delete" method="post">
<a asp-action="Edit"
class="btn btn-sm btn-warning" asp-route-id="@p.Id">
Edit
</a>
<input type="hidden" name="id" value="@p.Id" />
<button type="submit" class="btn btn-danger btn-sm">
Delete
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
<a asp-action="Create" class="btn btn-primary">Create New Product</a>
使用dotnet run
启动应用程序并导航至 http://localhost:5000 来查看结果,如图11-6所示。应用程序的 MVC 部分已经完成,存储库模式已经实现,为下一章添加功能奠定了基础。
本章我演示了如何向 ASP.NET Core MVC 应用程序添加 Entity Framework Core,并向您展示了应用存储库模式的不同方法。我将在接下来的章节中增强示例项目,从下一章开始,其中我将介绍 Entity Framework Core 支持的基本数据操作。
;